13  Эмбеддинги на основе PMI-матрицы

Как упоминалось в предыдущем уроке, векторная модель может основываться не на матрице термин-документ, а на матрице термин-термин. В этом случае моделируется именно пространство слов: информацию о документах мы при этом теряем (хотя это поправимо).

Для удобства сравнения двух подходов мы возьмем тот же новостной датасет, которым пользовались для построения LSA модели.

library(plyr)
library(tidyverse)
library(tidytext)
load("../data/news_tokens_pruned.Rdata")
news_tokens_pruned 

13.1 Скользящее окно

Прежде всего разделим новости на контекстные окна фиксированной величины. Чем меньше окно, тем больше синтаксической информации оно хранит.

library(tidyr)

nested_news <- news_tokens_pruned |> 
  dplyr::select(-topic) |> 
  nest(tokens = c(token))

nested_news
slide_windows <- function(tbl, window_size) {
  skipgrams <- slider::slide(
    tbl, 
    ~.x, 
    .after = window_size - 1, 
    .step = 1, 
    .complete = TRUE
  )
  
  safe_mutate <- safely(mutate)
  
  out <- map2(skipgrams,
              1:length(skipgrams),
              ~ safe_mutate(.x, window_id = .y))
  
  out %>%
    transpose() %>%
    pluck("result") %>%
    compact() %>%
    bind_rows()
}

Деление на окна может потребовать нескольких минут. Чем больше окно, тем больше потребуется времени и тем больше будет размер таблицы.

news_windows <- nested_news |> 
  mutate(tokens = map(tokens, slide_windows, 10L)) %>% 
  unnest(tokens) %>% 
  unite(window_id, id, window_id)

news_windows
load("../data/news_windows.Rdata")

13.2 Что такое PMI

Обычная мера ассоциации между словами, которой пользуются лингвисты, — точечная взаимная информация, или PMI (pointwise mutual information). Она рассчитывается по формуле:

\[PMI\left(x;y\right)=\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}}\]

В числителе — вероятность встретить два слова вместе (например, в пределах одного документа или одного «окна» длинной n слов). В знаменателе — произведение вероятностей встретить каждое из слов отдельно. Если слова чаще встречаются вместе, логарифм будет положительным; если по отдельности — отрицательным.

Посчитаем PMI на наших данных, воспользовавшись подходящей функцией из пакета widyr.

library(widyr)
news_pmi  <- news_windows  |> 
  pairwise_pmi(token, window_id)
news_pmi |> 
  arrange(-abs(pmi))

13.3 Почему PPMI

В отличие от коэффициента корреляции, например, PMI может варьироваться от \(-\infty\) до \(+\infty\), но негативные значения проблематичны. Они означают, что вероятность встретить эти два слова вместе меньше, чем мы бы ожидали в результате случайного совпадения. Проверить это без огромного корпуса невозможно: если у нас есть \(w_1\) и \(w_2\), каждое из которых встречается с вероятностью \(10^{-6}\), то трудно удостовериться в том, что \(p(w_1, w_2)\) значимо отличается от \(10^{-12}\). Поэтому негативные значения PMI принято заменять нулями. В таком случае формула выглядит так:

\[ PMI\left(x;y\right)=max(\log{\frac{P\left(x,y\right)}{P\left(x\right)P\left(y\right)}},0) \] Для подобной замены подойдет векторизованное условие.

news_ppmi <- news_pmi |> 
  mutate(ppmi = case_when(pmi < 0 ~ 0, 
                          .default = pmi)) 

news_ppmi |> 
  arrange(pmi)

Если мы развернем такую матрицу вширь, то она получится очень разреженной; чтобы получить плотные векторы слов, необходимо прибегнуть к SVD.

13.4 SVD на матрице с PPMI

Для этого можно передать тиббл фунции widely_svd() для вычисления сингулярного разложения. Обратите внимание на аргумент weight_d: если задать ему значение FALSE, то вернутся не эмбеддинги, а матрица левых сингулярных векторов:

word_emb <- news_ppmi |> 
  widely_svd(item1, item2, ppmi,
             weight_d = FALSE, nv = 100) |> 
  rename(word = item1) # иначе nearest_neighbors() будет жаловаться
word_emb

13.5 Визуализация топиков

Визуализируем главные компоненты нашего векторного пространства.

word_emb |> 
  filter(dimension < 10) |> 
  group_by(dimension) |> 
  top_n(10, abs(value)) |> 
  ungroup() |> 
  mutate(word = reorder_within(word, value, dimension)) |> 
  ggplot(aes(word, value, fill = dimension)) +
  geom_col(alpha = 0.8, show.legend = FALSE) +
  facet_wrap(~dimension, scales = "free_y", ncol = 3) +
  scale_x_reordered() +
  coord_flip() +
  labs(
    x = NULL, 
    y = "Value",
    title = "Первые 9 главных компонент за 2019 г.",
    subtitle = "Топ-10 слов"
  ) +
  scale_fill_viridis_c()

13.6 Ближайшие соседи

Исследуем наши эмбеддинги, используя уже знакомую функцию, которая считает косинусное сходство между словами.

source("../helper_scripts/nearest_neighbors.R")
word_emb |> 
  nearest_neighbors("сборная")
word_emb |> 
  nearest_neighbors("завод")

13.7 От эмбеддингов слов к эмбеддингам документов

Используя документы как суммы входящих в них слов, мы может использовать эмбеддинги для нахождения ближайших документов в корпусе.

Первая матрица хранит информацию о встречаемости слов в документах; в рядах здесь документы; в столбцах – слова (всего 6299).

counts_mx <- news_tokens_pruned %>%
  count(id, token) %>%
  cast_sparse(id, token, n)
dim(counts_mx)
[1] 3407 6299

Вторая матрица – это наши эмбеддинги в разреженном виде. Число столбцов соответствует числу сингулярных векторов, которое мы задали при факторизации.

embedding_mx <- word_emb %>%
  cast_sparse(word, dimension, value)
dim(embedding_mx)
[1] 6299  100

Перемножение матрицы \((3407 \times 6299)\) на \((6299 \times 20)\) вернет матрицу размером \((3407 \times 20)\), то есть мы получим эмбеддинги для документов.

doc_mx <- counts_mx %*% embedding_mx
dim(doc_mx)
[1] 3407  100

13.8 Похожие документы

Превратим разреженную матрицу обратно в датафрейм.

doc_emb_long <- doc_mx |> 
  as.matrix() |> 
  as.data.frame() |> 
  rownames_to_column("doc") |> 
  pivot_longer(-doc, names_to = "dimension", values_to = "value")
nearest_neighbors(doc_emb_long, "doc14", doc = TRUE)

Познакомимся с соседями.

load("../data/news.Rdata")
news_2019 |> 
  mutate(id = paste0("doc", row_number())) |> 
  filter(id %in% c("doc14", "doc1", "doc1000", "doc1142")) |> 
  mutate(text = str_trunc(text, 70)) 

Релевантная выдача здесь – только первый документ. С этой задачей LSA в нашем случае справилась лучше. Посмотрим теперь на семантическое пространство слов. Для этого придется снизить размерность со 100 до 2 измерений.

13.9 2D-визуализации пространства слов

word_emb_mx <- as.matrix(embedding_mx)

Для снижения размерности мы используем алгоритм UMAP. В отличие от PCA, он снижает размерность нелинейно, и в этом отношении похож на t-SNE.

library(uwot)
set.seed(02062024)
viz <- umap(word_emb_mx,  n_neighbors = 15, n_threads = 2)

Как видно по размерности матрицы, все наши слова вложены теперь в двумерное пространство.

dim(viz)
[1] 6299    2
tibble(word = rownames(word_emb_mx), 
       V1 = viz[, 1], 
       V2 = viz[, 2]) |> 
  ggplot(aes(x = V1, y = V2, label = word)) + 
  geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
   annotate(geom = "rect", ymin = 1.5, ymax = 5.5, xmin = 1.5, xmax = 6.5, alpha = 0.2, color = "tomato")+
  theme_light()

Посмотрим на выделенный фрагмент этой карты.

tibble(word = rownames(word_emb_mx), 
       V1 = viz[, 1], 
       V2 = viz[, 2]) |> 
  filter(V1 > 1.5 & V1 < 6.5) |> 
  filter(V2 > 1.5 & V2 < 5.5) |> 
  ggplot(aes(x = V1, y = V2, label = word)) + 
  geom_text(size = 2, alpha = 0.4, position = position_jitter(width = 0.5, height = 0.5)) +
  theme_light()

13.10 Математическое волшебство

Если слова – это векторы, то их можно складывать и вычитать, как мы это делали в школе. Согласно известному примеру, если из вектора слова “король” вычесть вектор “мужчина” и прибавить вектор “женщина”, получатся числа, соответствующие слову “королева”. Правда, у нас слишком небольшой датасет, так что на озарения лучше не рассчитывать.

library(plyr)

mystery_word <- unrowname(word_emb_mx["спортсмен",] + word_emb_mx["мяч",]) 

head(mystery_word)
          1           2           3           4           5           6 
 0.02899943 -0.01946437  0.12604935  0.02450570 -0.03578517 -0.01520623 
mystery_tbl <- tibble(
  word = "mystery",
  dimension = as.numeric(names(mystery_word)),
  value = as.numeric(mystery_word)
)
word_emb_new <- word_emb |> 
  bind_rows(mystery_tbl)

nearest_neighbors(word_emb_new, "mystery")

Отличная работа 🏈

13.11 Сглаженная PMI (DPF)

Как уже было сказано, для редких слов PMI оказывается завышена: т.к. вероятность встретить их в корпусе очень мала, знаменатель в формуле PMI тоже уменьшается. Иными словами, существует негативная корреляция между частотностью слова и его PMI.

Есть несколько способов если не совсем избавиться от негативной корреляции, то по крайней мере уменьшить ее абсолютное значение. Один из них заключается в том, чтобы чуть завысить знаменатель в формуле PMI, например, возведя его в дробную степень. Такой подход применялся в недавнем исследовании по цифровой истории идей, авторы которого предлагают использовать меру под названием DPF (Distributional Probability Factor). Показатель степени α при этом устанавливается в районе α = 0.75.

\[ DPF\ \left(x;y\right)=\frac{P\left(x,y\right)}{(P\left(x\right)P{\left(y\right))}^\alpha} \]

Второй способ известен как сглаживание Лапласа, оно же аддитивное сглаживание. В этом случае мы прибавляем единицу к частоте каждого слова, как будто видели его на один раз больше (этот способ задействуется и в некоторых алгоритмах машинного обучения).

Применять сглаживание функция pairwise_pmi() не умеет, поэтому на этот раз мы посчитаем взаимную информацию чуть иначе.

# вероятность для каждого слова
unigram_probs <- news_tokens_pruned  |> 
  dplyr::count(token, sort = TRUE)  |> 
  mutate(p = n / sum(n))

unigram_probs
# вероятность встретить слова вместе
bigram_probs <- news_tokens_pruned  |> 
  pairwise_count(token, id, diag = TRUE, sort = TRUE)  |> 
  mutate(p = n / sum(n))

bigram_probs |> 
  arrange(-n) # на этом этапе можно отфильтровать n < 2

Посчитаем нормализованную вероятность: это вероятность встретить два слова вместе, деленная на произведение вероятностей встретить каждое из них отдельно.

# взаимная информация
normalized_probs <- bigram_probs  |> 
  # filter(n > 4)  |> 
  dplyr::rename(word1 = item1, word2 = item2)  |> 
  left_join(unigram_probs  |> 
              select(word1 = token, p1 = p),
            by = "word1")  |> 
  left_join(unigram_probs %>%
              select(word2 = token, p2 = p),
            by = "word2")  |> 
  mutate(p_together = p / p1 / p2)

normalized_probs

Рассчитаем заново PMI и добавим сглаживание.

# pmi & dpf
pmi_data <- normalized_probs |> 
  mutate(pmi = log(p_together)) |> 
  mutate(dpf = p / (p1 * p2)^0.75) |> 
  mutate(ppmi = case_when(pmi < 0 ~ 0, 
                          .default = pmi))

pmi_data |> 
  arrange(pmi)

Сравним зависимость от частотности в трех случаях.

library(ggpubr)
# корреляция
pmi_data |>
  filter(word1 == "футболист") |> 
  filter(!word1 == word2) |> 
  select(word2, pmi, ppmi, dpf, p2) |> 
  pivot_longer(cols = c(pmi, dpf, ppmi), names_to = "measure", values_to = "value") |> 
  ggplot(aes(p2, value, color = value)) +
   geom_jitter(width = 0.0002, height = 0.0002, 
              show.legend = FALSE, alpha = 0.5) +
  xlim(NA, 0.0045) + # zoom in
  facet_wrap(~ measure, scales = "free_y") +
  stat_cor(aes(label = ..r.label..),
           method = "pearson",
           label.x = 0.002,
           color = "tomato",
           geom = "label"
  ) +
  theme_bw() +
  labs(title = "Корреляция между частотностью слова и PMI / DPF", 
       y = NULL)

Аналогично тому, что мы делали выше, можно снизить размерность DPF-матрицы при помощи SVD или же сохранить ее в разреженном виде для изучения совместной встречаемости слов.